Skip to content

KAFKA-20578: Initial producer incremental allocation for uncompressed data - #22654

Open
lianetm wants to merge 69 commits into
apache:trunkfrom
lianetm:lm-producer-dyn-1
Open

KAFKA-20578: Initial producer incremental allocation for uncompressed data#22654
lianetm wants to merge 69 commits into
apache:trunkfrom
lianetm:lm-producer-dyn-1

Conversation

@lianetm

@lianetm lianetm commented Jun 23, 2026

Copy link
Copy Markdown
Member

Initial partial implementation for KIP-1332 (producer incremental
allocation strategy).

This PR includes:

  • new producer config for allocation strategy (incremental/full)
  • initial implementation of the incremental strategy: supports
    uncompressed data only (no growth support), extra-copy on send (linked
    chunks are flattened into a new buffer)
  • unit and integration tests (running existing Producer integration
    tests with the new strategy + new ones)

Support for compressed data and network layer improvements will come in
follow-up PRs.

Reviewers: Jun Rao junrao@gmail.com, Ken Huang s7133700@gmail.com,
Chia-Ping Tsai chia7712@gmail.com

@lianetm
lianetm requested a review from junrao June 23, 2026 18:34
@github-actions github-actions Bot added core Kafka Broker producer build Gradle build or GitHub Actions clients labels Jun 23, 2026

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the PR. Made a pass of the non-testing files. Left a few comments.

Importance.MEDIUM,
CommonClientConfigs.CLIENT_DNS_LOOKUP_DOC)
.define(BUFFER_MEMORY_CONFIG, Type.LONG, 32 * 1024 * 1024L, atLeast(0L), Importance.HIGH, BUFFER_MEMORY_DOC)
.define(BUFFER_MEMORY_ALLOCATION_STRATEGY_CONFIG,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we mark this as internal to prevent it from leaking into 4.4 before the feature is fully implemented?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done, and created https://issues.apache.org/jira/browse/KAFKA-20763 to publish it once the feature is complete

// pool memory by completing batches). On exhaustion, close the batch (making it
// drainable) and fall through to the blocking first-record path next iteration.
try {
extensionChunks = chunkedFree.allocateChunks(extensionBytes, 0L);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to do a special non-blocking call? In the existing logic, if an allocation request is blocked, all existing ProducerBatches become immediately drainable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not strictly needed, agreed, it was just to fail fast and avoid what seemed like a "waisted" wait here, exactly because of the fact you mention, that blocking would make all open batches drainable, including this one we want to extend (and this is the trick I was trying to avoid, updated the comment that was misleading).

So with a blocking call, even if we get the pool memory we need in time, the batch would probably be gone/drained, and we would need to start a new batch anyways (so opting for non-blocking just to go straight to the new-batch phase if pool is exhausted when extending).

That being said, there is a case where blocking here would seem better, that's if the pool is exhausted at this point but it frees-up before the batch we're extending is drained. But that one didn't seem common in practice given that the sender drains batches before polling (so by the time memory frees up from a request that compelted, the batch we're waiting to extend would be drained already). Makes sense?

last.closeForRecordAppends();
}
}
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may take a bit of time for the closed batches to be drained. If we continue here, it seems that the client will just busy-loop until some batches are drained and some free space becomes available in buffer pool?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

even without the drain the closeForAppends should be enough to make that the next iteration find that the stream is closed => isFull => !hasRoom so starting a new batch (blocking, no busy loop). Makes sense? Added a test testExhaustedExtensionFallsBackToBlockingNewBatchPath to show the behaviour.

* them, and retries. Otherwise defers to the parent.
*/
@Override
protected RecordAppendResult tryAppend(long timestamp, byte[] key, byte[] value, Header[] headers,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit awkward to have a return value of null and RecordAppendResult.needsExtension. Could we introduce a non-null value to indicate the batch is full?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done. Introduced needsNewBatch (aligned with needsBufferExtension)

// ProducerBatch), which can't take extension chunks. Only attach to a
// writable chunked batch; otherwise refund the chunks and re-evaluate.
if (last instanceof ChunkedProducerBatch && last.isWritable()) {
((ChunkedProducerBatch) last).addBuffers(extensionChunks);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess two concurrent clients could add buffers exceeding the batch size? Those buffers won't be used, but can only be freed after the batch is drained.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, there could be over-reservation at this point with concurrent appends. As for how the extra buffers would be used/released, I see the following cases:

  • used right away: next loop iteration -> the loser's tryAppend right after this line fails with needsExtension (the winner already took the free capacity that was used to calculate this extension). Then the loser loops and tries again (recomputes the extension needed, so it uses what it had added concurrently + more)
  • left unused, freed when batch drains (case you mentioned) -> if the loser's record can't land in this batch (batch closes and loser's record goes into new batch).

Added 2 tests to cover this: testConcurrentExtensionRaceDoesNotOverflowChunkedStream and testBatchSizeLimitRespectedDespiteOverReservation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is a related case. Two concurrent producer each allocated its own extensionChunks. It's possible that the first producer's allocation is enough to fit the records from both producer. It would be useful to document that this logic optimizes for the common case, but can lead to slightly temporary over allocation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, agreed, added a comment describing it.

long remainingBytes = memoryRequired - (long) pooled.size() * chunkSize;
if (remainingBytes > 0) {
// remainingBytes <= memoryRequired <= totalMemory (validated above), so the int cast is safe.
freeUp((int) remainingBytes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a no-op since all pooled chunks have been used if we reach here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, removed.

pooled.add(free.pollFirst());
long remainingBytes = memoryRequired - (long) pooled.size() * chunkSize;
if (remainingBytes > 0) {
// remainingBytes <= memoryRequired <= totalMemory (validated above), so the int cast is safe.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is remainingBytes guaranteed to be an int? memoryRequired could be larger than int and pooled.size() initially could be 0.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, good catch, seemed safe here but it's not because of how memoryRequired is rounded up higher up. This code here does not exist anymore though so no changes (the other place where a similar unsafe op was being done to calculate stillNeeded went away too, removed with the comment/fix below)

}
long stillNeeded = memoryRequired - (long) pooled.size() * chunkSize - accumulated;
if (stillNeeded > 0) {
freeUp((int) stillNeeded);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be ok, but it's a bit weird to free up the chunks only to be reallocated again. Here is an alternative that doesn't require a freeup() call.

  // Reuse pooled chunks first. If a reused chunk covers a slot we already reserved as                                                                                                                                                                                                                                    
  // raw bytes in an earlier iteration, hand that raw reservation back to the pool.                                                                                                                                                                                                                                       
  while (pooled.size() < numChunks && !free.isEmpty()) {                                                                                                                                                                                                                                                                  
      pooled.add(free.pollFirst());                                                                                                                                                                                                                                                                                       
      if (accumulated >= chunkSize) {          // accumulated is always chunk-aligned here                                                                                                                                                                                                                                
          accumulated -= chunkSize;                                                                                                                                                                                                                                                                                       
          this.nonPooledAvailableMemory += chunkSize;   // refund → available to other waiters                                                                                                                                                                                                                            
      }                                                                                                                                                                                                                                                                                                                   
  }                                                                                                                                                                                                                                                                                                                       
  // Reserve raw memory for any still-uncovered chunks, in whole chunks.                                                                                                                                                                                                                                                  
  while (pooled.size() + (int)(accumulated / chunkSize) < numChunks                                                                                                                                                                                                                                                       
          && this.nonPooledAvailableMemory >= chunkSize) {                                                                                                                                                                                                                                                                
      this.nonPooledAvailableMemory -= chunkSize;                                                                                                                                                                                                                                                                         
      accumulated += chunkSize;                                                                                                                                                                                                                                                                                           
  }                                                                                                                                                                                                                                                                                                                       

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, makes sense, done.


// Take pool chunks first, then reserve non-pool bytes for the remainder.
while (pooled.size() < numChunks
&& (long) (pooled.size() + 1) * chunkSize + accumulated <= memoryRequired

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first condition is redundant, given the second one.

 (pooled+1)*chunkSize + accumulated ≤ memoryRequired                                                                                                                                                                                                                                                               
    ⇒  (pooled+1)*chunkSize ≤ memoryRequired − accumulated ≤ memoryRequired = numChunks*chunkSize                                                                                                                                                                                                                         
    ⇒  pooled+1 ≤ numChunks                                                                                                                                                                                                                                                                                               
    ⇒  pooled < numChunks

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed (the condition changed though, with the comment above, and does not have the redundancy anymore, so no changes directly for this)

// On failure (timeout / close / interrupt), refund the non-pool bytes taken.
// Pool chunks already in `pooled` are returned to `free` separately by the
// outer catch.
this.nonPooledAvailableMemory += accumulated;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we return accumulated and pooled chunks in the same place? For example, we can set a flag like allocationCompleted to replace accumulated = 0. Then we can free both accumulated and pooled chunks if the flag is not set.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Introduced the allocationCompleted flag and now all refunds happen together in a finally, much better indeed.

@lianetm

lianetm commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review @junrao , all comments addressed.

About the high level one (this) on the overlap between RecordAccumulator and ChunkedRecordAccumulator, totally agree. I refactored to extract and reuse different common bits for now (keeping both classes still, just common helpers, along the lines of the alternative 1). Compression will touch the core of this, so wonder if better to wait to see how those changes fit here, to see how much common surface we still have and then decide if continue with the helpers approach, taking them a step further, or go with some like the alt 2 approach. Makes sense? if so I would create a jira on me to review/dedup along with the compression changes.

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. Just a few minor comments.

* strategy), {@link #CHUNKED} only {@link #allocateChunks} (the incremental strategy). Fixed at
* construction so the two are never mixed on the same pool.
*/
public enum AllocationMode { SINGLE, CHUNKED }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it better to use FULL and INCREMENTAL?

throw new IllegalStateException("needsNewBatch path reached without an allocated buffer stream");
// Reuse the new-batch size estimate as the write-limit basis.
// TODO: review when compression is supported.
final NewBatchBuffer pending = newBatch;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pending => pendingNewBatch?

*/
private static final class NewBatchBuffer {
final ChunkedByteBufferOutputStream stream;
final int size;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

size => firstAppendSize?


// Now free chunks one at a time, allowing the multi-chunk request to accumulate.
p.deallocate(h1);
Thread.sleep(50);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it need to sleep for 50ms, which is long for a unit test? Ditto below.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeap not needed in this case really, and the other one can be better done with a waitForCondition on the available memory, done.

} catch (Throwable th) {
err.set(th);
}
}, "slow-success");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slow-success => chunked-waiter ?

@lianetm

lianetm commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

Thanks @junrao ! All comments addressed.

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. A few more comments.

// Use the chunked path only when a batch is at least one full chunk
// (batch.size >= CHUNK_SIZE). Below that, a batch can't fill even one chunk, so chunking
// would over-reserve and the producer falls back to the full strategy instead.
boolean useIncremental = incremental && batchSize >= ChunkedRecordAccumulator.CHUNK_SIZE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we switch automatically to full because of the batch size, it might be useful to log a warning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, done

// A single call can guarantee at most `chunkSize` of space (the stream advances one chunk
// at a time). Callers needing more attach chunks via addBuffers first. write(byte[]) loops
// across chunks, so contiguous capacity isn't required.
ensureChunkCapacity(Math.min(remainingBytesRequired, chunkSize));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this still seems to only work if remainingBytesRequired is 1. If it's larger than 1 and the current chunk doesn't have enough remaining bytes, we move to the next chunk. A subsequent write will write on the new chunk, the remaining bytes on the previous chunk are wasted.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeap, agreed, fixed. Also added a todo to review this bit when compression and the need to grow lands

/**
* Appends the record after checking there's chunk capacity for it.
* At this point it's expected that the allocated chunks have
* capacity for the record because the accumulator never routes an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point it's expected that the allocated chunks have capacity

Hmm, is this true? This method could return null because the batch is full, right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, updated the javadoc to clarify

/** Total available memory is the sum of nonPooledAvailableMemory and the number of byte buffers in free * poolableSize. */
private long nonPooledAvailableMemory;
/** Lock held for any read or write of {@link #free}, {@link #waiters}, {@link #nonPooledAvailableMemory}, or {@link #closed}. */
protected final ReentrantLock lock;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this still need to be protected? Ditto for other fields. We can also remove the addition of BufferPool to spotbugs-exclude.xml.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, all private now and cleaned up the spotbugs

* Wake the longest-waiting thread if any memory (pooled or non-pooled) is available.
* Must be called with {@link #lock} held. No-op if no waiters or no memory is free.
*/
protected void signalNextWaiterIfMemoryAvailable() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be private.

* full strategy (allocate) and the incremental strategy (ChunkedRecordAccumulator),
* so both update the same buffer-exhausted metrics.
*/
protected void recordBufferExhausted() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can have package level visibility.

* waiter. Acquires {@link #lock} internally. Used by callers
* that reserve memory and then need to roll back the reservation (e.g., upon errors).
*/
protected void releaseReservedBytes(long bytes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be private.

* buffers (if needed). Must be called with {@link #lock} held.
*/
private void freeUp(int size) {
protected void freeUp(int size) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be private.

public class ChunkedProducerBatch extends ProducerBatch {

public ChunkedProducerBatch(TopicPartition tp, MemoryRecordsBuilder recordsBuilder, long createdMs) {
super(tp, recordsBuilder, createdMs);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we validate that recordsBuilder.bufferStream() is chunked?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, done

newBatchSize, chunkedFree.poolableSize(), topic, effectivePartition, maxTimeToBlock);
List<ByteBuffer> initialChunks;
try {
initialChunks = chunkedFree.allocateChunks(newBatchSize, maxTimeToBlock);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that we can block on the bufferpool longer than maxTimeToBlock. Claude suggested the following path that doesn't exist in RecordAccumulator.

Iteration A: needsNewBatch → blocking allocateChunks(_, maxTimeToBlock) succeeds after a wait. A concurrent thread created a batch meanwhile, so appendNewBatch's in-lock tryAppend returns needsBufferExtension → line 257 deallocates the stream, continue.
Iteration B: extension path does the non-blocking allocateChunks(gap, 0L) → BufferExhaustedException → closes the batch (line 168) → continue.
Iteration C: batch now closed → needsNewBatch → blocks again for a fresh full maxTimeToBlock.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed, tracking remainingTimeToBlock along the whole path (which goes as you described above)

Iteration A consumes from it, B does not touch it because it's non-blocking, C consumes from what's left (fixing the gap)

throw new IllegalArgumentException("totalSize must be positive: " + totalSize);

int chunkSize = poolableSize();
int numChunks = (int) (((long) totalSize + chunkSize - 1L) / chunkSize);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

int numChunks = (totalSize - 1) / chunkSize + 1; is overflow-safe even without the cast. The existing code is correct, so feel free to ignore this comment.

@lianetm

lianetm commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Thanks @junrao ! All comments addressed.

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. A couple of more comments.

} finally {
// Update the remaining time to block.
nowMs = time.milliseconds();
remainingTimeToBlock = Math.max(0L, remainingTimeToBlock - (nowMs - allocationStartMs));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remainingTimeToBlock = Math.max(0L, remainingTimeToBlock - Math.max(0L, nowMs - allocationStartMs)); ?

log.trace("Allocating {} byte chunked buffer ({} byte chunks) for topic {} partition {} with remaining timeout {}ms",
newBatchSize, chunkedFree.poolableSize(), topic, effectivePartition, remainingTimeToBlock);
List<ByteBuffer> initialChunks;
long allocationStartMs = time.milliseconds();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an extra time.milliseconds() call on the critical path. Could you do some perf test between full and incremental to verify there is no degradataion?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long story short, the extra time doesn't seem to have a relevant impact on append, but the repeated record size calculation on extensionBytesNeeded does, related to what we discussed in the comment above, but the numbers I ran now do show the impact even without headers. I included a partial improvement here now, and will add more as a follow-up with https://issues.apache.org/jira/browse/KAFKA-20859 (just avoiding it in this PR as it moves quite a few things)

Now the longer story, I ran a JMH comparison of the two accumulators append (will add the benchmarks in a separate PR)

1 - Batch creation path (every append to a different partition, so every one creates a batch): the extra time.milliseconds accounts for only 3.5% of the difference between the strategies at default batch.size (and it's per batch, not per record, above code is the new batch path). Overall on this path incremental is +21% at batch.size=16KB but −42% at bigger batch size 256KB (cold pool, not "steady" state)

2 - Steady-state append (records landing in an existing batch) is where the numbers show an impact, with 100-byte records: +36% at default batch.size and +45% at bigger batch.size 256KB. At 16KB, extensionBytesNeeded is around 91% of that gap (the record gets sized twice per append). The partial fix I added in this PR is to scoped the capacity check in ChunkedProducerBatch.tryAppend to a batch's first record only (the only append where the caller does not validate so we need to). That improves it to +17.6% at 16KB and +37.6% at 256KB. I will close the gap with what we know for now (follow-up with KAFKA-20859 right away), and run it all again there to see where we get.

Makes sense?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this PR, we just need to make sure the default full allocation strategy doesn't degrade. Do you have some perf numbers with the default allocation strategy without and with this PR?

// drained and replaced — possibly by a split batch (a plain
// ProducerBatch), which can't take extension chunks. Only attach to a
// writable chunked batch; otherwise refund the chunks and re-evaluate.
if (last instanceof ChunkedProducerBatch && last.isWritable()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude found the following issue. So, the existing implementation of ProducerBatch.isWritable() is incorrect. It's just never used in production before.

ChunkedRecordAccumulator.java:210 gates the chunk attach on last.isWritable(), but that predicate doesn't mean what the call site needs:

ProducerBatch.isWritable() (ProducerBatch.java:570-572) is !recordsBuilder.isClosed()
MemoryRecordsBuilder.isClosed() (:919-921) is builtRecords != null — "records have been built", not "stream is open for appends"
NoCompression.wrapForOutput returns the stream itself, so closeForRecordAppends() → appendStream.close() really does reach ChunkedByteBufferOutputStream.close() and set closed = true
So a batch closed for appends reports isWritable() == true while its stream is already closed

As for the fix, Claude suggested replacing last.isWritable() with extensionBytesNeeded(...) > 0. It fixes this issue and also avoids the potential over-attaching issue below. If we do that, we probably want to remove isWritable since its implementation is incorrect and is not used in production.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeap, good point, makes sense. Fixed with the suggestion + test (and removed the isWritable too)

Time time,
TransactionManager transactionManager,
BufferPool bufferPool) {
super(logContext, batchSize, compression, lingerMs, retryBackoffMs, retryBackoffMaxMs,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we verify the pool is INCREMENTAL mode with poolableSize() == CHUNK_SIZE?

assertThrows(BufferExhaustedException.class, () -> p.allocateChunks(2 * chunkSize, 0));

// Available memory must reflect only the chunk we deliberately hold; the request's
// first-chunk acquisition was rolled back.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first-chunk acquisition was rolled back

This is misleading since the 2-chunk allocation throws and nothing was acquired.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed, updated the comments and aligned test name

@lianetm

lianetm commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Thanks @junrao ! All comments addressed (will open parallel PRs to publish the benchmarks and https://issues.apache.org/jira/browse/KAFKA-20859)

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. A couple of minor comments. Could we run some producer perf test without and with this PR to make sure there is no perf degradation with the default setting?

ProducerBatch last = dq.peekLast();
// The batch may have changed while allocateChunks was off-lock: drained and
// replaced, closed for appends, filled to its limit, or already grown by a
// concurrent appender. The instanceof excludes a replacement that cannot take

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about the following?

                        // concurrent appender. extensionBytesNeeded is 0 in all of those cases, so it
                        // serves as both tests at once: the batch still needs chunks for this record,
                        // and it is still open (attaching to a stream closed for appends would throw).
                        // The instanceof covers the one case it cannot: a replacement that is a plain
                        // ProducerBatch (a split batch), which takes no chunks at all.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, just minor tweak, that "extensionBytestNeeded is 0 in all those cases" was not true for all really, it could be > 0 in the first case (if the batch was drained and replaced, by another chunked batch that needs chunks for this record)


// The extension chunks acquired for the closed batch must have been refunded, so the only
// memory still held beyond the first batch is what the new batch needed.
assertTrue(pool.availableMemory() < availableBeforeSecond,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't ensure that the extension chunks acquired for the closed batch must have been refunded.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, fixed to assert correctly

@lianetm

lianetm commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

So I ran some benchmarks, focused on the shared code that default strategy runs (it's the append). Found an impact indeed, a small ~3% regression on the steady state, and this is what was behind it:estimatedBytesWritten was recomputing batchHeaderSize instead of using the cached field, silly miss (introduced when making a func static).

It's fixed now, and it removed the ~3% diff. Details on the benchmark: ran in on the the accumulator append, with default and bigger batch.size (256 KB), without compression, and different message value sizes (benchmark code in separate PR). Will open it in another PR, didn't find any existing perf test that would cover this append path.

@junrao junrao left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lianetm : Thanks for the updated PR. LGTM

Could you also run the accumulator append perf test with compression to make sure there is no degradation?

@lianetm

lianetm commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Could you also run the accumulator append perf test with compression to make sure there is no degradation?

yes, on it, I'm still running it, included compression already (draft benchmark I'm running here), will share numbers to confirm before merging.

log.trace("Pool exhausted while extending batch for topic {} partition {}; closing existing batch",
topic, partition);
synchronized (dq) {
ProducerBatch last = dq.peekLast();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude suggested the following. Is it worth fixing? This can be done in a followup PR.

On pool exhaustion it re-takes the lock and closes dq.peekLast() — which may be a different, freshly-created batch than the one whose gap it sized, since the sizing happened before the failed off-lock acquire. Not corruption, but under exhaustion + concurrency it prematurely rolls healthy batches. Capture the batch identity and close only if it's still the same one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build Gradle build or GitHub Actions ci-approved clients core Kafka Broker producer tools

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants